Leaflet Blog in Deno Fresh
1import { Handlers, PageProps } from "$fresh/server.ts";
2import { Layout } from "../../islands/layout.tsx";
3import { PostInfo } from "../../components/post-info.tsx";
4import { Title } from "../../components/typography.tsx";
5import { getPost } from "../../lib/api.ts";
6import { Head } from "$fresh/runtime.ts";
7import { TextBlock } from "../../components/TextBlock.tsx";
8import {
9 PubLeafletBlocksHeader,
10 PubLeafletBlocksImage,
11 PubLeafletBlocksText,
12 PubLeafletBlocksUnorderedList,
13 PubLeafletPagesLinearDocument,
14} from "npm:@atcute/leaflet";
15import { h } from "preact";
16
17interface Post {
18 uri: string;
19 value: {
20 title?: string;
21 description?: string;
22 pages?: PubLeafletPagesLinearDocument.Main[];
23 publishedAt?: string;
24 };
25}
26
27export const handler: Handlers<Post> = {
28 async GET(_req, ctx) {
29 try {
30 const { slug } = ctx.params;
31 const post = await getPost(slug);
32 return ctx.render(post);
33 } catch (error) {
34 console.error("Error fetching post:", error);
35 return new Response("Post not found", { status: 404 });
36 }
37 },
38};
39
40function Block({
41 block,
42 did,
43 isList,
44}: {
45 block: PubLeafletPagesLinearDocument.Block;
46 did: string;
47 isList?: boolean;
48}) {
49 let b = block;
50
51 let className = `
52 postBlockWrapper
53 pt-1
54 ${
55 isList
56 ? "isListItem pb-0 "
57 : "pb-2 last:pb-3 last:sm:pb-4 first:pt-2 sm:first:pt-3"
58 }
59 ${
60 b.alignment === "lex:pub.leaflet.pages.linearDocument#textAlignRight"
61 ? "text-right"
62 : b.alignment === "lex:pub.leaflet.pages.linearDocument#textAlignCenter"
63 ? "text-center"
64 : ""
65 }
66 `;
67
68 if (b.block.$type === "pub.leaflet.blocks.unorderedList") {
69 return (
70 <ul className="-ml-[1px] sm:ml-[9px] pb-2">
71 {b.block.children.map((child, index) => (
72 <ListItem item={child} did={did} key={index} className={className} />
73 ))}
74 </ul>
75 );
76 }
77
78 if (b.block.$type === "pub.leaflet.blocks.image") {
79 const imageBlock = b.block as PubLeafletBlocksImage.Main;
80 const image = imageBlock.image as { ref: { $link: string } };
81 const alt = imageBlock.alt || "";
82 const aspect = imageBlock.aspectRatio;
83 let width = aspect?.width;
84 let height = aspect?.height;
85 // Fallback to default size if not provided
86 if (!width) width = 600;
87 if (!height) height = 400;
88 return (
89 <img
90 src={`/api/atproto_images?did=${did}&cid=${image.ref.$link}`}
91 alt={alt}
92 width={width}
93 height={height}
94 className={`!pt-3 sm:!pt-4 ${className}`}
95 style={{
96 aspectRatio: width && height ? `${width} / ${height}` : undefined,
97 }}
98 />
99 );
100 }
101
102 if (b.block.$type === "pub.leaflet.blocks.text") {
103 return (
104 <div className={` ${className}`}>
105 <TextBlock facets={b.block.facets} plaintext={b.block.plaintext} />
106 </div>
107 );
108 }
109
110 if (b.block.$type === "pub.leaflet.blocks.header") {
111 const header = b.block as PubLeafletBlocksHeader.Main;
112 const level = header.level || 1;
113 const Tag = `h${Math.min(level + 1, 6)}` as keyof h.JSX.IntrinsicElements;
114 // Add heading styles based on level
115 let headingStyle =
116 "font-serif font-bold tracking-wide uppercase mt-8 break-words text-wrap ";
117 switch (level) {
118 case 1:
119 headingStyle += "text-3xl lg:text-4xl";
120 break;
121 case 2:
122 headingStyle += "text-3xl border-b pb-2 mb-6";
123 break;
124 case 3:
125 headingStyle += "text-2xl";
126 break;
127 case 4:
128 headingStyle += "text-xl";
129 break;
130 case 5:
131 headingStyle += "text-lg";
132 break;
133 case 6:
134 headingStyle += "text-base";
135 break;
136 default:
137 headingStyle += "text-2xl";
138 }
139 return (
140 <Tag className={headingStyle + " " + className}>
141 <TextBlock plaintext={header.plaintext} facets={header.facets} />
142 </Tag>
143 );
144 }
145
146 return null;
147}
148
149function ListItem(props: {
150 item: PubLeafletBlocksUnorderedList.Main["children"][number];
151 did: string;
152 className?: string;
153}) {
154 return (
155 <li className={`!pb-0 flex flex-row gap-2`}>
156 <div
157 className={`listMarker shrink-0 mx-2 z-[1] mt-[14px] h-[5px] w-[5px] rounded-full bg-secondary`}
158 />
159 <div className="flex flex-col">
160 <Block block={{ block: props.item.content }} did={props.did} isList />
161 {props.item.children?.length
162 ? (
163 <ul className="-ml-[7px] sm:ml-[7px]">
164 {props.item.children.map((child, index) => (
165 <ListItem
166 item={child}
167 did={props.did}
168 key={index}
169 className={props.className}
170 />
171 ))}
172 </ul>
173 )
174 : null}
175 </div>
176 </li>
177 );
178}
179
180export default function BlogPage({ data: post }: PageProps<Post>) {
181 if (!post) {
182 return <div>Post not found</div>;
183 }
184
185 const firstPage = post.value.pages?.[0];
186 let blocks: PubLeafletPagesLinearDocument.Block[] = [];
187 if (firstPage?.$type === "pub.leaflet.pages.linearDocument") {
188 blocks = firstPage.blocks || [];
189 }
190 // Deduplicate blocks by $type and plaintext
191 const seen = new Set();
192 const uniqueBlocks = blocks.filter((b) => {
193 const key = b.block.$type + "|" + ((b.block as any).plaintext || "");
194 if (seen.has(key)) return false;
195 seen.add(key);
196 return true;
197 });
198
199 const content = uniqueBlocks
200 .filter((b) => b.block.$type === "pub.leaflet.blocks.text")
201 .map((b) => (b.block as PubLeafletBlocksText.Main).plaintext)
202 .join(" ");
203
204 return (
205 <>
206 <Head>
207 <title>{post.value.title} — knotbin</title>
208 <meta
209 name="description"
210 content={post.value.description || "by Roscoe Rubin-Rottenberg"}
211 />
212 </Head>
213
214 <Layout>
215 <div class="p-8 pb-20 gap-16 sm:p-20">
216 <link rel="alternate" href={post.uri} />
217 <div class="max-w-[600px] mx-auto">
218 <article class="w-full space-y-8">
219 <div class="space-y-4 w-full">
220 <Title>{post.value.title || "Untitled"}</Title>
221 {post.value.description && (
222 <p class="text-xl italic md:text-2xl font-serif leading-relaxed max-w-prose">
223 {post.value.description}
224 </p>
225 )}
226 <PostInfo
227 content={content}
228 createdAt={post.value.publishedAt || new Date().toISOString()}
229 includeAuthor
230 className="text-sm"
231 />
232 <div class="diagonal-pattern w-full h-3" />
233 </div>
234 <div class="postContent flex flex-col">
235 {uniqueBlocks.map((block, index) => (
236 <Block
237 block={block}
238 did={post.uri.split("/")[2]}
239 key={index}
240 />
241 ))}
242 </div>
243 </article>
244 </div>
245 </div>
246 </Layout>
247 </>
248 );
249}